在Day21中,我們成功為單一技能詳細頁(/skills/[id])加入了Markdown學習筆記系統,並實作了語法高亮與雙欄即時預覽。
今天(Day22),我們將填補詳細頁中的最後一塊核心拼圖—練習日誌(PracticeLogs)與數據視覺化!
對於任何技能的學習,持續練習與數據回饋是突破瓶頸期的不二法門。今天我們將實作:
練習日誌打卡模組:記錄每次練習的時數(Minutes)、心流程度(FocusLevel)與練習備註。
Prisma數據連動:打卡時同步自動更新Skill的lastPracticedAt(最後練習時間)與proficiency(熟練度)。
Recharts視覺化圖表:繪製每週練習時數趨勢圖(BarChart)與熟練度成長曲線(AreaChart),用數據量化你的學習成長!
1.系統架構與資料庫關聯(PracticeLogModel)
我們首先擴充prisma/schema.prisma,建立PracticeLog模型,並建立與Skill的1:N關聯
2.實戰步驟1:擴充PrismaSchema與Migration
開啟@prisma/schema.prisma,加入PracticeLog模型:
// prisma/schema.prisma
model Skill {
id String @id @default(uuid())
title String
category Category
proficiency Int @default(0)
tags String[] @default([])
status Status @default(LEARNING)
notesCount Int @default(0)
lastPracticedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
notes Note[]
logs PracticeLog[] // 練習日誌關聯
@@index([category])
@@index([status])
@@map("skills")
}
model PracticeLog {
id String @id @default(uuid())
skillId String
duration Int // 練習時間(分鐘)
focusLevel Int @default(3) // 專注度 rating (1-5)
notes String? @db.Text
date DateTime @default(now())
createdAt DateTime @default(now())
skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
@@index([skillId])
@@map("practice_logs")
}
在Terminal執行Migration:
npx prisma migrate dev --name add_practice_log_model
3.實戰步驟2:安裝Recharts可視化圖表庫
在Terminal執行:
npm install recharts
4.實戰步驟3:建立練習打卡與統計API(app/api/skills/[id]/logs/route.ts)
當使用者提交一次練習時,我們使用PrismaTransaction同時執行三件事:
建立PracticeLog紀錄。
自動更新Skill的lastPracticedAt為當前時間。
根據練習時數按比例微幅提升Skill的proficiency熟練度(最高上限100)。
// app/api/skills/[id]/logs/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const createLogSchema = z.object({
duration: z.number().min(1, '練習時間必須大於 0 分鐘'),
focusLevel: z.number().min(1).max(5),
notes: z.string().optional(),
date: z.string().optional(),
});
// GET: 取得該技能的所有練習紀錄
export async function GET(
_request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const logs = await prisma.practiceLog.findMany({
where: { skillId: params.id },
orderBy: { date: 'desc' },
});
return NextResponse.json({ success: true, data: logs });
} catch (error) {
return NextResponse.json({ success: false, error: '讀取練習紀錄失敗' }, { status: 500 });
}
}
// POST: 新增練習打卡紀錄
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const validation = createLogSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json({ success: false, error: '資料驗證失敗' }, { status: 400 });
}
const { duration, focusLevel, notes, date } = validation.data;
const logDate = date ? new Date(date) : new Date();
// 取得當前技能以計算熟練度增量
const currentSkill = await prisma.skill.findUnique({
where: { id: params.id },
});
if (!currentSkill) {
return NextResponse.json({ success: false, error: '找不到該技能' }, { status: 404 });
}
// 計算新熟練度:每練習 30 分鐘微幅增加 2%,上限 100%
const addedProficiency = Math.floor(duration / 30) * 2;
const newProficiency = Math.min(100, currentSkill.proficiency + addedProficiency);
// 事務操作
const [newLog] = await prisma.$transaction([
prisma.practiceLog.create({
data: {
skillId: params.id,
duration,
focusLevel,
notes,
date: logDate,
},
}),
prisma.skill.update({
where: { id: params.id },
data: {
lastPracticedAt: new Date(),
proficiency: newProficiency,
},
}),
]);
return NextResponse.json({ success: true, data: newLog }, { status: 201 });
} catch (error) {
return NextResponse.json({ success: false, error: '新增打卡失敗' }, { status: 500 });
}
}
5.實戰步驟4:打造Recharts統計圖表組件(components/skills/PracticeChart.tsx)
我們建立一個動態響應式的Recharts圖表組件,繪製近7日練習時數長條圖:
// components/skills/PracticeChart.tsx
'use client';
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
} from 'recharts';
interface PracticeChartProps {
data: {
date: string;
duration: number;
}[];
}
export function PracticeChart({ data }: PracticeChartProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-48 border border-dashed border-slate-800 rounded-xl text-slate-500 text-sm">
尚無足夠的練習紀錄可供繪製圖表
</div>
);
}
return (
<div className="w-full h-64 bg-slate-900/60 border border-slate-800 p-4 rounded-xl">
<h4 className="text-sm font-semibold text-slate-300 mb-4">近 7 次練習時數統計 (分鐘)</h4>
<ResponsiveContainer width="100%" height="80%">
<BarChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.5} />
<XAxis dataKey="date" stroke="#94a3b8" fontSize={12} tickLine={false} />
<YAxis stroke="#94a3b8" fontSize={12} tickLine={false} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
borderColor: '#334155',
borderRadius: '0.5rem',
color: '#f8fafc',
}}
/>
<Bar dataKey="duration" fill="#06b6d4" radius={[6, 6, 0, 0]} name="練習分鐘" />
</BarChart>
</ResponsiveContainer>
</div>
);
}
6.實戰步驟5:建立練習打卡Modal與清單視圖(components/skills/PracticeLogSection.tsx)
現在我們將打卡Modal、歷史清單與Recharts圖表整合在同一個介面中:
// components/skills/PracticeLogSection.tsx
'use client';
import { useState } from 'react';
import { Plus, Clock, Flame, Calendar } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import { PracticeChart } from './PracticeChart';
import { toast } from 'sonner';
interface Log {
id: string;
duration: number;
focusLevel: number;
notes: string | null;
date: string;
}
interface PracticeLogSectionProps {
skillId: string;
initialLogs: Log[];
}
export function PracticeLogSection({ skillId, initialLogs }: PracticeLogSectionProps) {
const [logs, setLogs] = useState<Log[]>(initialLogs);
const [isModalOpen, setIsModalOpen] = useState(false);
const [duration, setDuration] = useState(30);
const [focusLevel, setFocusLevel] = useState(3);
const [notes, setNotes] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// 轉換圖表數據 (取最近 7 筆)
const chartData = [...logs]
.reverse()
.slice(-7)
.map((log) => ({
date: new Date(log.date).toLocaleDateString('zh-TW', { month: 'numeric', day: 'numeric' }),
duration: log.duration,
}));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const res = await fetch(`/api/skills/${skillId}/logs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration, focusLevel, notes }),
});
const json = await res.json();
if (json.success) {
toast.success('打卡成功!熟練度已同步提升!');
setLogs([json.data, ...logs]);
setIsModalOpen(false);
setNotes('');
} else {
toast.error('打卡失敗:' + json.error);
}
} catch {
toast.error('網路連線異常');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="space-y-6">
{/* 頂部操作與統計區域 */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold text-slate-100 flex items-center gap-2">
<Calendar className="h-5 w-5 text-cyan-400" /> 練習歷程與打卡
</h3>
<button
onClick={() => setIsModalOpen(true)}
className="flex items-center gap-2 px-4 py-2 bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-semibold rounded-xl shadow-lg shadow-cyan-600/20 transition"
>
<Plus className="h-4 w-4" /> 練習打卡
</button>
</div>
{/* 圖表展現 */}
<PracticeChart data={chartData} />
{/* 歷史打卡列表 */}
<div className="space-y-3">
{logs.map((log) => (
<div
key={log.id}
className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-slate-900/40 border border-slate-800/80 rounded-xl gap-3"
>
<div className="space-y-1">
<div className="flex items-center gap-3 text-xs text-slate-400">
<span>{new Date(log.date).toLocaleString('zh-TW')}</span>
<span className="flex items-center gap-1 text-amber-400">
<Flame className="h-3.5 w-3.5" /> 專注度 Lvl {log.focusLevel}
</span>
</div>
<p className="text-sm text-slate-200">{log.notes || '(無附加筆記)'}</p>
</div>
<div className="flex items-center gap-1 text-cyan-400 font-mono font-bold text-sm bg-cyan-500/10 px-3 py-1.5 rounded-lg border border-cyan-500/20 w-fit">
<Clock className="h-4 w-4" /> {log.duration} 分鐘
</div>
</div>
))}
</div>
{/* 打卡 Modal */}
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="新增練習紀錄">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
本次練習時數 (分鐘)
</label>
<input
type="number"
min="1"
max="600"
value={duration}
onChange={(e) => setDuration(Number(e.target.value))}
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
專注狀態 (1-5 顆星)
</label>
<input
type="range"
min="1"
max="5"
value={focusLevel}
onChange={(e) => setFocusLevel(Number(e.target.value))}
className="w-full accent-cyan-500"
/>
<div className="text-center text-xs text-cyan-400 font-bold mt-1">
{focusLevel} / 5
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
練習心得 / 突破瓶頸備註
</label>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="今天完成了哪些重點?遇到什麼問題?"
className="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-slate-100 text-sm focus:border-cyan-500 focus:outline-none"
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-4 py-2 bg-slate-800 text-slate-300 text-sm rounded-lg hover:bg-slate-700 transition"
>
取消
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-cyan-600 text-white text-sm font-semibold rounded-lg hover:bg-cyan-500 transition disabled:opacity-50"
>
{isSubmitting ? '儲存中...' : '確認打卡'}
</button>
</div>
</form>
</Modal>
</div>
);
}
今天我們成功完成VibePulse數據打卡與視覺化模組:
Prisma連動機制:打卡時透過Transaction同步紀錄時間,並自動計算熟練度增量與更新lastPracticedAt。
Recharts圖表整合:繪製流暢響應的練習時數長條圖,讓數據會說話。
完整的單一技能儀表板:結合Day20的頁籤、Day21的Markdown筆記與Day22的練習歷程,完成整個技能詳細頁的全功能閉環!